#!/usr/bin/env node
import chalk from "chalk";
import { Option, program } from "commander";
import { getApiKey, resetApiKey } from "./auth.js";
import { FileConfig, generateConfig, getFileConfig } from "./configuration.js";
import {
	Action,
	LinkType,
	MatchMode,
	PROGRAM_NAME,
	PROGRAM_VERSION,
} from "./constants.js";
import { db } from "./db.js";
import { updateTorrentCache } from "./decide.js";
import { diffCmd } from "./diff.js";
import { CrossSeedError } from "./errors.js";
import { clearIndexerFailures } from "./indexers.js";
import { injectSavedTorrents, restoreFromTorrentCache } from "./inject.js";
import { jobsLoop } from "./jobs.js";
import { bulkSearch, scanRssFeeds } from "./pipeline.js";
import { sendTestNotification } from "./pushNotifier.js";
import { serve } from "./server.js";
import { withFullRuntime, withMinimalRuntime } from "./startup.js";
import { indexTorrentsAndDataDirs, parseTorrentFromPath } from "./torrent.js";
import { fallback } from "./utils.js";

let fileConfig: FileConfig;
try {
	fileConfig = await getFileConfig();
} catch (e) {
	if (e instanceof CrossSeedError) {
		console.error(e.message);
		process.exit(1);
	}
	throw e;
}

const apiKeyOption = new Option(
	"--api-key <key>",
	"Provide your own API key to override the autogenerated one.",
).default(fileConfig.apiKey);

function createCommandWithSharedOptions(name: string, description: string) {
	return program
		.command(name)
		.description(description)
		.option(
			"-T, --torznab <urls...>",
			"Torznab urls with apikey included (separated by spaces)",
			// @ts-expect-error commander supports non-string defaults
			fallback(fileConfig.torznab),
		)
		.option(
			"--use-client-torrents",
			"Use torrents from your client for matching",
			fallback(fileConfig.useClientTorrents, false),
		)
		.option(
			"--no-use-client-torrents",
			"Don't use torrents from your client for matching",
		)
		.option(
			"--ignore-non-relevant-files-to-resume",
			"Ignore certain known irrelevant files when calculating resume size",
			fallback(fileConfig.ignoreNonRelevantFilesToResume, false),
		)
		.option(
			"--no-ignore-non-relevant-files-to-resume",
			"Don't ignore certain known irrelevant files when calculating resume size",
		)
		.option(
			"--data-dirs <dirs...>",
			"Directories to use if searching by data instead of torrents (separated by spaces)",
			// @ts-expect-error commander supports non-string defaults
			fallback(fileConfig.dataDirs),
		)
		.addOption(
			new Option(
				"--match-mode <mode>",
				`"strict" will require all file names to match exactly. "flexible" allows for file renames. "partial" is like "flexible" but it ignores small files like .nfo/.srt if missing.`,
			)
				.default(fallback(fileConfig.matchMode, MatchMode.STRICT))
				.choices(Object.values(MatchMode))
				.makeOptionMandatory(),
		)
		.option(
			"--skip-recheck",
			"Skip rechecking torrents before resuming, unless necessary.",
			fallback(fileConfig.skipRecheck, true),
		)
		.option(
			"--no-skip-recheck",
			"Recheck every torrent before resuming, even if unnecessary.",
		)
		.option(
			"--auto-resume-max-download <number>",
			"The maximum size in bytes remaining for a torrent to be resumed",
			parseInt,
			fallback(fileConfig.autoResumeMaxDownload, 52428800),
		)
		.option(
			"--link-category <cat>",
			"Torrent client category to set on linked torrents",
			fallback(fileConfig.linkCategory, "cross-seed-link"),
		)
		.option(
			"--link-dir <dir>",
			"Directory to link the data for matches to",
			fileConfig.linkDir,
		)
		.option(
			"--link-dirs <dirs...>",
			"Directories to link the data for matches to",
			// @ts-expect-error commander supports non-string defaults
			fallback(fileConfig.linkDirs),
		)
		.option(
			"--flat-linking",
			"Use flat linking directory structure (without individual tracker folders)",
			fallback(fileConfig.flatLinking, false),
		)
		.addOption(
			new Option(
				"--link-type <type>",
				"Use links of this type to inject data-based matches into your client",
			)
				.default(fallback(fileConfig.linkType, LinkType.SYMLINK))
				.choices(Object.values(LinkType))
				.makeOptionMandatory(),
		)
		.option(
			"--max-data-depth <depth>",
			"Max depth to look for searchees in dataDirs",
			(n) => parseInt(n),
			fallback(fileConfig.maxDataDepth, 2),
		)
		.option(
			"-i, --torrent-dir <dir>",
			"Directory with torrent files",
			fileConfig.torrentDir,
		)
		.option(
			"-s, --output-dir <dir>",
			"Directory to save results in",
			fileConfig.outputDir,
		)
		.option(
			"--include-non-videos",
			"Include torrents which contain non-video files",
			fallback(fileConfig.includeNonVideos, false),
		)
		.option(
			"--no-include-non-videos",
			"Don't include torrents which contain non-videos",
		)
		.option(
			"--include-single-episodes",
			"Include single episode torrents in the search",
			fallback(fileConfig.includeSingleEpisodes, false),
		)
		.option(
			"--no-include-single-episodes",
			"Don't include single episode torrents in the search",
		)
		.option(
			"--season-from-episodes <decimal>",
			"Match season packs from episode torrents",
			parseFloat,
			fallback(fileConfig.seasonFromEpisodes, null),
		)
		.option(
			"--no-season-from-episodes",
			"Don't match season packs from episode torrents",
		)
		.option(
			"--fuzzy-size-threshold <decimal>",
			"The size difference allowed to be considered a match.",
			parseFloat,
			fallback(fileConfig.fuzzySizeThreshold, 0.02),
		)
		.option(
			"-x, --exclude-older <cutoff>",
			"Exclude torrents first seen more than n minutes ago. Bypasses the -a flag.",
			fileConfig.excludeOlder,
		)
		.option(
			"-r, --exclude-recent-search <cutoff>",
			"Exclude torrents which have been searched more recently than n minutes ago. Bypasses the -a flag.",
			fileConfig.excludeRecentSearch,
		)
		.option("-v, --verbose", "Log verbose output", false)
		.addOption(
			new Option(
				"-A, --action <action>",
				"If set to 'inject', cross-seed will attempt to add the found torrents to your torrent client.",
			)
				.default(fallback(fileConfig.action, Action.SAVE))
				.choices(Object.values(Action)),
		)
		.option(
			"--torrent-clients <clients...>",
			"The the client prefix, readonly status, and urls of your torrent clients.",
			// @ts-expect-error commander supports non-string defaults
			fallback(fileConfig.torrentClients, []),
		)
		.option(
			"--rtorrent-rpc-url <url>",
			"The url of your rtorrent XMLRPC interface.",
			fileConfig.rtorrentRpcUrl,
		)
		.option(
			"--qbittorrent-url <url>",
			"The url of your qBittorrent webui.",
			fileConfig.qbittorrentUrl,
		)
		.option(
			"--transmission-rpc-url <url>",
			"The url of your Transmission RPC interface.",
			fileConfig.transmissionRpcUrl,
		)
		.option(
			"--deluge-rpc-url <url>",
			"The url of your Deluge JSON-RPC interface.",
			fileConfig.delugeRpcUrl,
		)
		.option(
			"--duplicate-categories",
			"Create and inject using categories with the same save paths as your normal categories",
			fallback(fileConfig.duplicateCategories, false),
		)
		.option(
			"--notification-webhook-urls <urls...>",
			"cross-seed will send POST requests to these urls with a JSON payload of { title, body, extra }",
			// @ts-expect-error commander supports non-string defaults
			fileConfig.notificationWebhookUrls,
		)
		.option(
			"--notification-webhook-url <url>",
			"cross-seed will send POST requests to this url with a JSON payload of { title, body, extra }",
			fileConfig.notificationWebhookUrl,
		)
		.option(
			"-d, --delay <delay>",
			"Pause duration (seconds) between searches",
			parseFloat,
			fallback(fileConfig.delay, 30),
		)
		.option(
			"--snatch-timeout <timeout>",
			"Timeout for unresponsive snatches",
			fallback(fileConfig.snatchTimeout, "30 seconds"),
		)
		.option(
			"--search-timeout <timeout>",
			"Timeout for unresponsive searches",
			fallback(fileConfig.searchTimeout, "2 minutes"),
		)
		.option(
			"--search-limit <number>",
			"The number of searches before stops",
			(n) => parseInt(n),
			fallback(fileConfig.searchLimit, 0),
		)
		.option(
			"--block-list <strings...>",
			"The infohashes and/or strings in torrent name to block from cross-seed",
			// @ts-expect-error commander supports non-string defaults
			fallback(fileConfig.blockList, []),
		)
		.option(
			"--sonarr <urls...>",
			"Sonarr API URL(s)",
			// @ts-expect-error commander supports non-string defaults
			fileConfig.sonarr,
		)
		.option(
			"--radarr <urls...>",
			"Radarr API URL(s)",
			// @ts-expect-error commander supports non-string defaults
			fileConfig.radarr,
		);
}

program.name(PROGRAM_NAME);
program.description(chalk.yellow.bold(`${PROGRAM_NAME} v${PROGRAM_VERSION}`));
program.version(PROGRAM_VERSION, "-V, --version", "output the current version");

program
	.command("gen-config")
	.description("Generate a config file")
	.action(withMinimalRuntime(generateConfig));

program
	.command("update-torrent-cache-trackers")
	.description("Update announce urls in the torrent cache")
	.usage("<old-announce-url> <new-announce-url>")
	.argument(
		"old-announce-url",
		'A substring of the announce url to replace, e.g. update-torrent-cache-trackers "old.example.com" "new.example.com"',
	)
	.argument(
		"new-announce-url",
		'A substring of the new announce url to replace the old one with, e.g. update-torrent-cache-trackers "myoldpasskey" "mynewpasskey"',
	)
	.action(withMinimalRuntime(updateTorrentCache));

program
	.command("diff")
	.description("Analyze two torrent files for cross-seed compatibility")
	.argument("searchee")
	.argument("candidate")
	.action(withMinimalRuntime(diffCmd, { migrate: false }));

program
	.command("tree")
	.description("Print a torrent's file tree")
	.argument("torrent")
	.action(
		withMinimalRuntime(
			async (torrentPath: string) => {
				console.log(
					"Use `cross-seed diff` to compare two .torrent files",
				);
				// eslint-disable-next-line @typescript-eslint/no-unused-vars
				const { category, isSingleFileTorrent, raw, tags, ...meta } =
					await parseTorrentFromPath(torrentPath);
				console.log(meta);
			},
			{ migrate: false },
		),
	);

program
	.command("clear-indexer-failures")
	.description("Clear the cached details of indexers (failures and caps)")
	.action(withMinimalRuntime(clearIndexerFailures));

program
	.command("clear-cache")
	.description(
		"Clear the cache without causing torrents to be re-snatched and reset the timestamps for excludeOlder and excludeRecentSearch",
	)
	.action(
		withMinimalRuntime(async () => {
			console.log("Clearing cache...");
			await db("decision").whereNull("info_hash").del();
			await db("timestamp").del();
		}),
	);

program
	.command("clear-client-cache")
	.description(
		"Clear cross-seed's cache of your client's torrents. Only necessary if you have recently changed clients or modified the torrents in client and don't want to wait on the cleanup job.",
	)
	.action(
		withMinimalRuntime(async () => {
			console.log("Clearing client cache...");
			await db("torrent").del();
			await db("client_searchee").del();
			await db("data").del();
			await db("ensemble").del();
		}),
	);

program
	.command("api-key")
	.description("Show the api key")
	.addOption(apiKeyOption)
	.action(withMinimalRuntime(getApiKey));

program
	.command("reset-api-key")
	.description("Reset the api key")
	.action(withMinimalRuntime(resetApiKey));

createCommandWithSharedOptions("daemon", "Start the cross-seed daemon")
	.option(
		"-p, --port <port>",
		"Listen on a custom port",
		(n) => parseInt(n),
		fallback(fileConfig.port, 2468),
	)
	.option("--host <host>", "Bind to a specific IP address", fileConfig.host)
	.option("--no-port", "Do not listen on any port")
	.option(
		"--search-cadence <cadence>",
		"Run searches on a schedule. Format: https://github.com/vercel/ms",
		fileConfig.searchCadence,
	)
	.option(
		"--rss-cadence <cadence>",
		"Run an rss scan on a schedule. Format: https://github.com/vercel/ms",
		fileConfig.rssCadence,
	)
	.addOption(apiKeyOption)
	.action(
		withFullRuntime(async (options) => {
			await indexTorrentsAndDataDirs({ startup: true });
			// technically this will never resolve, but it's necessary to keep the process running
			await Promise.all([serve(options.port, options.host), jobsLoop()]);
		}),
	);

createCommandWithSharedOptions("rss", "Run an rss scan").action(
	withFullRuntime(async () => {
		await indexTorrentsAndDataDirs({ startup: true });
		await scanRssFeeds();
	}),
);

createCommandWithSharedOptions("search", "Search for cross-seeds")
	.addOption(
		new Option(
			"--torrents <torrents...>",
			"torrent files separated by spaces",
		).hideHelp(),
	)
	.option(
		"--no-exclude-older",
		"Don't Exclude torrents based on when they were first seen.",
	)
	.option(
		"--no-exclude-recent-search",
		"Don't Exclude torrents based on when they were last searched.",
	)
	.action(withFullRuntime(() => bulkSearch()));

createCommandWithSharedOptions(
	"inject",
	"Inject saved cross-seeds into your client (without filtering, see docs)",
)
	.option(
		"--inject-dir <dir>",
		"Directory of torrent files to try to inject",
		fallback(fileConfig.injectDir, fileConfig.outputDir),
	)
	.option(
		"--ignore-titles",
		"Searchee and candidate titles do not need to pass the fuzzy matching check (useful if `cross-seed inject` erroneously rejects by title)",
		fallback(fileConfig.ignoreTitles, false),
	)
	.option(
		"--no-ignore-titles",
		"Searchee and candidate titles need to pass the fuzzy matching check (default)",
	)
	.action(withFullRuntime(injectSavedTorrents));

createCommandWithSharedOptions(
	"restore",
	"Use snatched torrents from torrent_cache to attempt to restore cross seeds. Will need to run `cross-seed inject` afterwards with dataDirs configured.",
).action(withFullRuntime(restoreFromTorrentCache));

createCommandWithSharedOptions(
	"test-notification",
	"Send a test notification",
).action(withFullRuntime(sendTestNotification));

program.showHelpAfterError("(add --help for additional information)");

await program.parseAsync();
